Amazon Athena Nested-JSONのSESログファイルを検索する
Amazon Athenaは、JSONSerDe とSERDEPROPERTIESの「マッピング」を使うことで、Nested-JSON(入れ子のJSON)のデータファイルをテーブルとして定義することができます。今回はその応用例として、SESのログファイルのテーブル定義とその検索方法について解説します。
目次
はじめに
Amazon SES は、メール送信イベントレコードを JSON 形式で Amazon Kinesis Firehose に発行します。そして、最終的に Firehose を経由して 出力した S3 上のファイルを Amazon Athena で検索します。以降では、Nested-JSONの定義に必要となる複合型(Complex Type)カラムの定義とマッピングについて解説した後、実際にSESのログを検索する方法をご紹介します。
SES や Firehose の設定については以下を参照してください。
複合型(Complex Type)カラムを定義する方法
Amazon Athenaは、intやstringといった基本的な型(Primitive Type)以外に struct(構造体)、map(マップ)、array(配列) といった複合型(Complex Type)があります。これらは入れ子になった形式やマップ、配列の形式をパースしてカラムに定義するのに必要です。
array (配列)
arrayは「array<型>」で定義します。
jsonファイル
下記の例では、namesという要素名に foo、barという値の配列を表しています。
{"names":["foo", "bar"]}
テーブル定義
テーブル定義では、namesというカラムに対して、namesというarray(配列型)を指定します。
CREATE EXTERNAL TABLE array_sample (names array) ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe' LOCATION 's3://<bucket_name>/array_sample/';
SELECTの例
namesカラムを参照すると配列の内容がそのまま表示されます。
> SELECT names FROM array_sample; names ---------- [foo, bar]
namesカラムの配列の値をレコードとして取得するには、「CROSS JOIN UNNEST」構文を利用します。
> SELECT name FROM array_sample CROSS JOIN UNNEST(names) AS t (name); name ----- foo bar
struct (構造体)
structは「struct<要素名:型, …>」で定義します。
jsonファイル
下記の例では、sという要素名に 要素名aと要素名bが定義されています。
{"s":{"a":"foo" ,"b":"bar"}}
テーブル定義
テーブル定義では、sカラムは要素名aと要素名bの2つの要素名とその値を定義します。
CREATE EXTERNAL TABLE struct_sample (s struct<a:string, b:string>) ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe' LOCATION 's3://<bucket_name>/struct_sample/';
SELECTの例
sカラムを参照すると構造体の内容がそのまま表示されます。
> SELECT s FROM struct_sample; s -------------- {a=foo, b=bar}
sカラムの中の要素の値を参照するにはs.aやs.bのようにカラムを指定して、それぞれの値を取得します。
> SELECT s.a, s.b FROM struct_sample; a | b -----+----- foo | bar
map(マップ)
mapは「map<キーの型, 値の型>」で定義します。
jsonファイル
下記の例では、sという要素名に 要素名aと要素名bが定義されています。このjsonファイルはstruct(構造体)のものと同様です。
{"s":{"a":"foo", "b":"bar"}}
テーブル定義
struct(構造体)のときはstructとして定義しましたが、マップでは要素名をキー、値をバリューとして定義します。
CREATE EXTERNAL TABLE map_sample (s map<string, string>) ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe' LOCATION 's3://<bucket_name>/map_sample/';
SELECTの例
sカラムを参照すると構造体の内容がそのまま表示されます。
> SELECT s FROM map_sample; s -------------- {a=foo, b=bar}
マップのデータとして定義することで、sの連想配列として値が取得できるようになります。
> SELECT s['a'] as a, s['b'] as b FROM map_sample; a | b -----+----- foo | bar
Nested-JSON(入れ子JSON)ファイル検索
早速、Nested-JSON(入れ子JSON)ファイルを検索してみましょう。ここでは基本的な例と、カラムの「マッピング」の定義が必要な例を紹介します。
1. 基本的なNested-JSONの例
jsonファイル
下記の例では、要素名sの下に要素名a、更にその下に要素名bが入れ子になっているjsonファイルです。
{"s":{"a":{"b":"foo"}}}
テーブル定義
ご想像の通り、一般的なNested-JSONのパースは、「s struct<a:struct>」のように、struct(構造体)を階層的に定義することで定義可能です。
CREATE EXTERNAL TABLE nested_sample1 (s struct<a:struct<b:string>>) ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe' LOCATION 's3://<bucket_name>/nested_json1/';
SELECTの例
データの参照は、「s」や「s.a」や「s.a.b」とカラム指定することで、それぞれ参照したい階層のデータにアクセスできます。
> SELECT s FROM nested_sample1; s ----------- {a={b=foo}} > SELECT s.a FROM nested_sample1; a ------- {b=foo} > SELECT s.a.b FROM nested_sample1; b ------ foo
2. カラムの「マッピング」を利用したNested-JSONの例
下記の例は、要素名「a:1」や「b:1」の用に要素名に「:」コロンを含むNested-JSONの例です。
jsonファイル
{"s":{"a:1":{"b:1":"foo"}}}
テーブル定義
これまでと同じように、struct(構造体)を階層的に定義を当てはめると、「s struct<a:1:struct<b:1:string>>」の様になり、要素名に「:」コロンに含まれ、要素名と値を区別できない問題が生じます。
そこで登場するのがカラムの「マッピング」です。SERDEPROPERTIES セクションで要素名「a:1」は要素名a1、要素名「b:1」は要素名b1という別名に再マップができます。この再マップした別名を使って、struct(構造体)を階層的に定義を当てはめて「s struct<a1:struct<b1:string>>」のように定義して問題を回避します。
CREATE EXTERNAL TABLE nested_sample2 (s struct<a1:struct<b1:string>>) ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe' WITH SERDEPROPERTIES ( "mapping.a1"="a:1", "mapping.b1"="b:1" ) LOCATION 's3://<bucket_name>/nested_json2/';
SELECTの例
カラムの「マッピング」で指定した別の要素名で階層的にデータを参照できます。
> SELECT s FROM nested_sample2; s ------------- {a1={b1=foo}} > SELECT s.a1 FROM nested_sample2; a1 -------- {b1=foo} > SELECT s.a1.b1 FROM nested_sample2; b1 ---- foo
SESログの検索の応用
送信イベントのデータセット以下の形式のjsonファイルです。(実際のjsonファイルはインデントや改行を含まないminify形式のjsonファイル *1です。)
{ "eventType": "Send", "mail": { "timestamp": "2017-01-18T18:08:44.830Z", "source": "youraddress@example.com", "sourceArn": "arn:aws:ses:us-west-2:111222333:identity/youraddress@example.com", "sendingAccountId": "111222333", "messageId": "01010159b2c4471e-fc6e26e2-af14-4f28-b814-69e488740023-000000", "destination": ["success@simulator.amazonses.com"], "headersTruncated": false, "headers": [{ "name": "From", "value": "youraddress@example.com" }, { "name": "To", "value": "success@simulator.amazonses.com" }, { "name": "Subject", "value": "Bounced Like a Bad Check" }, { "name": "MIME-Version", "value": "1.0" }, { "name": "Content-Type", "value": "text/plain; charset=UTF-8" }, { "name": "Content-Transfer-Encoding", "value": "7bit" } ], "commonHeaders": { "from": ["youraddress@example.com"], "to": ["success@simulator.amazonses.com"], "messageId": "01010159b2c4471e-fc6e26e2-af14-4f28-b814-69e488740023-000000", "subject": "Test" }, "tags": { "ses:configuration-set": ["Firehose"], "ses:source-ip": ["54.55.55.55"], "ses:from-domain": ["amazon.com"], "ses:caller-identity": ["root"] } }, "send": {} }
これまで通り、jsonファイルは org.openx.data.jsonserde.JsonSerDe を使って、データのパースを行います。要素名mailは最大3階層に構造体として定義しています。 また、timestamp カラムや from カラムは Presto の予約語なのでバックォート「`」でエスケープする必要があります。 「ses:」からはじまる要素名については、SERDEPROPERTIES のマッピングセクションで再マッピングして、クエリを投げることができるように定義しています。
Firehose に発行された Amazon SES イベントデータの例 のデータに対して、以下のテーブル定義を指定したとします。
CREATE EXTERNAL TABLE sesmaster ( eventType string, complaint struct<arrivaldate:string, complainedrecipients:array<struct<emailaddress:string>>, complaintfeedbacktype:string, feedbackid:string, `timestamp`:string, useragent:string>, bounce struct<bouncedrecipients:array<struct<action:string, diagnosticcode:string, emailaddress:string, status:string>>, bouncesubtype:string, bouncetype:string, feedbackid:string, reportingmta:string, `timestamp`:string>, mail struct<`timestamp`:string, source:string, sourceArn:string, sendingAccountId:string, messageId:string, destination:string, headersTruncated:boolean, headers:array<struct<name:string,value:string>>, commonHeaders:struct<`from`:array<string>,to:array<string>,messageId:string,subject:string>, tags:struct<ses_configurationset:string,ses_source_ip:string,ses_outgoing_ip:string,ses_from_domain:string,ses_caller_identity:string> >, send string, delivery struct<processingtimemillis:int, recipients:array<string>, reportingmta:string, smtpresponse:string, `timestamp`:string> ) ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe' WITH SERDEPROPERTIES ( "mapping.ses_configurationset"="ses:configuration-set", "mapping.ses_source_ip"="ses:source-ip", "mapping.ses_from_domain"="ses:from-domain", "mapping.ses_caller_identity"="ses:caller-identity", "mapping.ses_outgoing_ip"="ses:outgoing-ip" ) LOCATION 's3://<bucket_name>/seslog/';
月曜日のキャンペーンで、宛先不明で戻ってきたメッセージはどれか?
SELECT eventtype as Event, mail.destination as Destination, mail.messageId as MessageID, mail.timestamp as Timestamp FROM sesmaster WHERE eventType = 'Bounce' and mail.timestamp like '2016-10-14%'; ;
あるドメインで、宛先不明で戻ってきたメッセージは何件か?
SELECT COUNT(*) as Bounces FROM sesmaster WHERE eventType = 'Bounce' and mail.destination like '%example.com%';
example.com ドメインに戻ってきたメッセージはどれか?
SELECT eventtype as Event, mail.destination as Destination, mail.messageId as MessageID FROM sesmaster WHERE eventType = 'Bounce' and mail.destination like '%example.com%';
全ての情報引き出すには
SELECT -- Event eventtype as Event, -- Complaint complaint.arrivaldate as Arrivaldate, complaint.complainedrecipients as ComplainedRecipients, complaint.complaintfeedbacktype as ComplaintFeedbackType, complaint.feedbackid as FeedbackID, complaint."timestamp" as ComplaintTimestamp, -- Bounce bounce.bouncedrecipients as BounceDrecipients, bounce.bouncesubtype as Bouncesubtype, bounce.bouncetype as Bouncetype, bounce.feedbackid as Feedbackid, bounce.reportingmta as Reportingmta, bounce."timestamp" as BounceTimestamp, -- Mail mail."timestamp" as MailTimestamp, mail.source as source, mail.sourceArn as Destination, mail.sendingAccountId as sourceArn, mail.messageId as MessageID, mail.destination as Destination, mail.headersTruncated as headersTruncated, mail.headers as headers, mail.commonHeaders as commonHeaders, mail.tags.ses_configurationset as ses_configurationset, mail.tags.ses_source_ip as ses_source_ip, mail.tags.ses_from_domain as ses_from_domain, mail.tags.ses_caller_identity as ses_caller_identity, mail.tags.ses_outgoing_ip as ses_outgoing_ip, -- Send send, -- Delivery delivery.processingtimemillis as ProcessingTimemillis, delivery.recipients as Recipients, delivery.reportingmta as ReportingMTA, delivery.smtpresponse as SMTPResponse, delivery.timestamp as DeliveryTimestamp FROM sesmaster;
最後に
Amazon Athenaで SESのような複雑なjsonファイルを検索するには以下の点の考慮が必要でした。
- 複合型(Complex Type)カラムと定義
- Nested-JSONのカラムの「マッピング」
- 予約語のエスケープ
Athenaで SESのログのような複雑なjsonファイルの検索ができるようになれば、どのようなjsonファイルでも検索できるといっても過言ではありません。
脚注
- minify形式のjsonファイル
Amazon Athenaがクエリーできるjsonファイルはminify形式のみです。prettyfy形式のjsonファイルはjqコマンド等を利用して変換が必要です。例えば、
cat <source_json> | jq -c > <dest_json>;
のように変換できます。 ↩